上一篇把問題攤開後,這回來寫最小版 Self-Consistency。多數決那一行很短,旁邊那些看似無聊的小事才會咬人。
我以前真的寫過這種一行版本:
Counter(answers).most_common(1)[0][0]
它會回傳最高票,看起來完全沒問題。麻煩是平手時,它直接拿最先出現的答案,整個過程安安靜靜,連警告都沒有。
我就被這件事坑過。當時模擬器把正確答案固定編成 0,argmax 又偏向最小索引,平手題於是白拿了一大段準確率。那份漂亮成績跟演算法無關。後來我把答案擷取、字串整理和平手處理拆開,才比較容易看出哪一步出錯。
我會要求模型把最後答案寫成:
FINAL: A
推理寫多長都沒關係,聚合器只讀最後一個 FINAL。模型沒照格式回答時,我寧可讓程式報錯,也不想把整段推理誤當答案。
import re
FINAL_RE = re.compile(
r'(?im)^\s*FINAL\s*:\s*(.+?)\s*$'
)
def normalize_answer(raw: str) -> str:
matches = FINAL_RE.findall(raw)
if not matches:
raise ValueError('找不到 FINAL: ...')
answer = matches[-1]
answer = re.sub(r'\s+', ' ', answer)
return answer.strip()
這裡只整理空白,沒有把所有文字一律轉成小寫。數學答案尤其不能靠幾個 replace 硬湊:0.5、1/2 和 LaTeX 分數可能是同一個值,我會留到答案等價那篇再處理。
from collections import Counter
from dataclasses import dataclass
@dataclass(frozen=True)
class VoteResult:
answer: str | None
counts: dict[str, int]
tied_answers: tuple[str, ...]
def plurality_vote(raw_outputs: list[str]) -> VoteResult:
if not raw_outputs:
raise ValueError('至少需要一筆模型輸出')
answers = [normalize_answer(x) for x in raw_outputs]
counts = Counter(answers)
top_count = max(counts.values())
winners = tuple(
sorted(
answer
for answer, count in counts.items()
if count == top_count
)
)
return VoteResult(
answer=winners[0] if len(winners) == 1 else None,
counts=dict(counts),
tied_answers=winners if len(winners) > 1 else (),
)
我讓平手時的 answer 回傳 None。真的非得選一個答案,可以用固定亂數種子抽籤,也可以再向模型多取幾條軌跡。選哪種都行,但規則要先寫進實驗設定,平手率也要留下來。
outputs = (
['推理略...\nFINAL: A'] * 9
+ ['推理略...\nFINAL: B'] * 5
+ ['推理略...\nFINAL: C'] * 2
)
result = plurality_vote(outputs)
print(result.answer)
print(result.counts)
print(result.tied_answers)
輸出會是:
A
{'A': 9, 'B': 5, 'C': 2}
()
投票器確認沒問題後,再把模型呼叫包進來:
from collections.abc import Callable
def self_consistency(
question: str,
ask: Callable[[str], str],
k: int = 16,
) -> VoteResult:
outputs = [ask(question) for _ in range(k)]
return plurality_vote(outputs)
ask 可以接任何 LLM API。我習慣把取樣和聚合分開,換模型或換 API 時,比較不會連投票器一起改壞。

圖:當多條推理軌跡高度相似時,16 票不等於 16 份獨立證據。群內相關越高,有效票數越快飽和;這也是後續需要另外處理重複軌跡的原因。
以前我只存最後答案,出問題時幾乎沒東西可以查。現在至少會留原始輸出、正規化答案、各答案票數、平手狀態、取樣參數和亂數種子。準確率突然變動時,才能查清楚是聚合器動了手腳,或只是這批樣本剛好不同。
這支程式跑通後,下一篇就替每條軌跡加上信心分數。正向加權只要改幾行,卻也正是高信心錯誤開始搶走票數的地方。
論文預印本:TACT: Trust-Anchored Confidence Tempering for Self-Consistency Voting in Large Language Models